---
title: "Synthetic Control: When You Have No Control Group"
description: "From the operational dilemma to SDID, a practitioner's guide to causal inference with aggregate units. With full R implementation."
date: 2026-09-07
categories: [causal inference, econometrics, synthetic control, difference-in-differences, R]
---
```{r setup}
#| include: false
pkgs <- c("ggplot2", "dplyr", "tidyr", "knitr", "kableExtra", "patchwork")
for (p in pkgs) {
if (!requireNamespace(p, quietly = TRUE))
install.packages(p, repos = "https://cloud.r-project.org", quiet = TRUE)
library(p, character.only = TRUE)
}
theme_set(
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold", size = 13),
plot.subtitle = element_text(colour = "grey40", size = 10),
axis.title = element_text(size = 10),
panel.grid.minor = element_blank()
)
)
set.seed(42)
```
# Introduction {#sec-intro}
Consider a setting common throughout applied econometrics: a single unit receives treatment at a known date, and a panel of untreated units is observed over the same period. The canonical example is Abadie, Diamond and Hainmueller's (2010) study of California's Proposition 99, but the structure recurs across policy evaluation, comparative politics, and industrial organization. One treated unit. A donor pool of untreated comparators. A pre-treatment window in which no unit is treated, and a post-treatment window in which only one is.
This structure poses a version of what Holland (1986) calls the fundamental problem of causal inference. The quantity of interest, the outcome the treated unit would have exhibited absent treatment, is never observed. It must be constructed from the data available on untreated units. The question that follows is not whether this construction is necessary but how it should be done, and this is where the standard toolkit of applied microeconometrics runs into difficulty.
Difference-in-differences is the default choice, and for good reason: it is transparent, well understood, and requires only two periods and two groups in its simplest form. Its identifying assumption, however, is that the treated and control units would have followed parallel trends absent treatment. With a single treated unit and a heterogeneous donor pool, this assumption is often untestable in any meaningful sense and, when pre-treatment trends are inspected, frequently rejected outright. A treated unit with a distinct pre-existing trajectory has no single comparator, and no unweighted average of comparators, that can serve as a credible counterfactual. Matching on the average of pre-treatment levels is not the same as matching on pre-treatment dynamics, and it is the dynamics that the parallel trends assumption requires.
The synthetic control method, developed by Abadie and Gardeazabal (2003) and formalized by Abadie, Diamond and Hainmueller (2010), addresses this gap directly. Rather than selecting a single comparison unit or averaging across a donor pool, it constructs a weighted combination of donor units chosen to reproduce the treated unit's pre-treatment trajectory as closely as possible, under the constraint that weights are non-negative and sum to one. This constraint restricts the synthetic unit to the convex hull of the donor pool, so it never rests on extrapolation beyond a combination of observed units. Where difference-in-differences imposes structural assumptions and hopes they hold, the synthetic control method makes the quality of the counterfactual visible and inspectable, in the form of the pre-treatment fit itself.
What follows sets out the method's formal structure as a linear factor model, why this structure nests difference-in-differences as a special case, the geometry of the convex hull constraint and what it buys over regression, the placebo-based inference required when the number of treated units is one, and the two principal extensions, augmented synthetic control (Ben-Michael, Feller and Rothstein, 2021) and synthetic difference-in-differences (Arkhangelsky et al., 2021), that relax its assumptions when pre-treatment fit is imperfect or multiple treated units are available.
# Why Standard Methods Break {#sec-did-fail}
## The Parallel Trends Problem
The Difference-in-Differences (DiD) estimator assumes **parallel trends**: in the absence of treatment, the temporal trajectory of the treated unit would have been parallel to that of the controls. Formally, the DiD estimator is:
$$
\hat{\tau}^{DiD} = \underbrace{(\bar{Y}_{PT,\text{post}} - \bar{Y}_{PT,\text{pre}})}_{\text{change in Portugal}} - \underbrace{(\bar{Y}_{C,\text{post}} - \bar{Y}_{C,\text{pre}})}_{\text{change in controls}}
$$
The structural problem: identification rests on $\mathbb{E}[Y_{PT,t}^{(0)} - Y_{PT,t-1}^{(0)}] = \mathbb{E}[Y_{C,t}^{(0)} - Y_{C,t-1}^{(0)}]$, i.e., that counterfactual trends are parallel. As Athey & Imbens (2017, p. 12) note:
> *"In the extreme case with only two groups and two periods, it is not clear whether we should assume that the percentage change over time in average outcomes would have been the same in the treatment and control groups in the absence of the treatment, or whether the level of the change over time would have been the same."*
In the fintech case, this assumption is indefensible. Portugal already had a distinct digital-adoption trajectory relative to Spain, France, or Greece. The pre-treatment trends visibly diverge before 2024. Parallelism is not just implausible, it is empirically rejected by the data you already have.
::: {.callout-important}
## Parallel trends is untestable in the post-treatment period
We can never observe the counterfactual trend for the treated unit after treatment occurs. What we *can* do is inspect pre-treatment trends as supporting evidence, if the groups were on parallel paths before treatment, it is more credible (though not certain) that they would have remained so. A visual pre-trend test is necessary, but it is not sufficient.
:::
```{r tbl-did-fail}
#| label: scm-tbl-did-fail
#| tbl-cap: "Why DiD fails with a single treated unit and non-parallel trends"
did_problems <- data.frame(
Problem = c(
"Divergent pre-treatment trends",
"Structural heterogeneity",
"Idiosyncratic shocks",
"Control contamination",
"Inference with N = 1"
),
Description = c(
"Portugal already had a distinct digital-adoption trajectory relative to Spain, France, or Greece, parallelism is visually rejected before 2024.",
"Portugal has a unique user profile, banking regulation (Banco de Portugal), and channel mix within the country sample.",
"A competitor entered Spain in Feb 2024, depressing Spanish metrics, control contamination.",
"Neighbouring countries may experience spill-over effects via the Portuguese diaspora.",
"With 1 treated unit, cluster-robust standard errors have N = 1 cluster; p-values are unreliable. Alternative small-sample methods (wild bootstrap, randomisation inference) exist but remain fragile."
),
Consequence = c(
"Level bias in ATT estimate",
"ES or FR trend is not informative about PT trend",
"DiD denominator absorbs part of the real effect",
"SUTVA violated, unit weights cannot correct this",
"Statistically unreliable inference"
),
check.names = FALSE
)
kable(did_problems, booktabs = TRUE, align = "lll") |>
kable_styling(
bootstrap_options = c("striped", "hover", "condensed"),
full_width = TRUE, font_size = 12
) |>
column_spec(1, bold = TRUE, width = "22%") |>
column_spec(2, width = "42%") |>
column_spec(3, width = "36%", color = "darkred")
```
## The Single Treated Unit Problem
DiD was designed for settings with many treated and control units, where aggregation smooths out idiosyncratic differences. With a single treated unit, two things go wrong simultaneously. First, you have no way to average out unit-specific shocks, one unusual event in Portugal post-launch gets absorbed into your treatment effect estimate. Second, conventional inference collapses: cluster-robust standard errors require multiple clusters to estimate a variance; with $N = 1$ treated cluster, your p-values are unreliable. Abadie et al. (2010) highlight this as a fundamental barrier, you cannot estimate a variance from one observation.
```{r fig-pretrends}
#| label: scm-fig-pretrends
#| fig-cap: "Divergent pre-treatment trajectories: Portugal vs. candidate comparators (simulated illustrative data). DiD requires these lines to be parallel, they clearly are not."
years <- 2018:2025
treat_time <- 2024
n <- length(years)
portugal <- 100 + cumsum(c(0, rnorm(n - 1, mean = 2.5, sd = 1.0)))
espanha <- 100 + cumsum(c(0, rnorm(n - 1, mean = 1.2, sd = 1.0)))
franca <- 100 + cumsum(c(0, rnorm(n - 1, mean = 1.5, sd = 0.8)))
grecia <- 100 + cumsum(c(0, rnorm(n - 1, mean = 0.7, sd = 1.1)))
portugal[years >= treat_time] <- portugal[years >= treat_time] +
cumsum(rep(3.5, sum(years >= treat_time)))
df_trends <- data.frame(
year = rep(years, 4),
index = c(portugal, espanha, franca, grecia),
country = rep(c("Portugal (treated)", "Spain", "France", "Greece"), each = n)
)
ggplot(df_trends, aes(x = year, y = index, colour = country, linetype = country,
linewidth = country)) +
geom_line() +
geom_vline(xintercept = treat_time, linetype = "dashed",
colour = "grey30", linewidth = 0.8) +
annotate("text", x = treat_time + 0.1, y = 115,
label = "Feature launch", hjust = 0, size = 3.3, colour = "grey30") +
scale_colour_manual(values = c(
"Portugal (treated)" = "#D62728",
"Spain" = "#1F77B4",
"France" = "#FF7F0E",
"Greece" = "#9467BD"
)) +
scale_linetype_manual(values = c(
"Portugal (treated)" = "solid",
"Spain" = "dashed",
"France" = "dotdash",
"Greece" = "dotted"
)) +
scale_linewidth_manual(values = c(
"Portugal (treated)" = 1.4,
"Spain" = 0.8,
"France" = 0.8,
"Greece" = 0.8
)) +
labs(
title = "90-Day Retention Index by Country",
subtitle = "Portugal grows systematically faster, parallel trends is indefensible",
x = "Year", y = "Retention Index (2018 = 100)",
colour = NULL, linetype = NULL, linewidth = NULL
) +
theme(legend.position = "bottom") +
annotate("label", x = 2019, y = 108,
label = "Pre-treatment divergence:\nParallel Trends violated",
colour = "#D62728", fill = "white",
fontface = "italic", size = 3)
```
**Conclusion:** We need a method that *constructs* the counterfactual rather than assuming it. This is where **Synthetic Control** comes in.
# The Core Idea: Building an Artificial Twin {#sec-core-idea}
The Synthetic Control Method (SCM) does something conceptually simple but methodologically powerful: instead of selecting *one* control country or taking an equal average of several, it constructs an **optimally weighted convex combination** of donor pool units that best replicates the pre-treatment characteristics of the treated unit.
As Abadie, Diamond & Hainmueller (2010) put it:
> *"The idea behind the synthetic control approach is that a combination of units often provides a better comparison for the unit exposed to the intervention than any single unit alone."*
Think of it as building a composite control group, stitching together pieces from different units to create a blend that mirrors the treated unit. You want Portugal's profile, but you don't have "Portugal without the feature." You have Spain, France, Italy, Greece. The SCM finds the recipe that best approximates it, perhaps 40% Spain, 30% Italy, 30% Greece, and uses that blend as the counterfactual. No single country matches Portugal, but a weighted combination can come remarkably close. The method lets the data decide which combination works best, replacing analyst judgment with algorithmic optimisation.
The diagram below summarises the synthetic control pipeline (adapted from [Mendez, 2023](https://carlos-mendez.org/post/r_basic_synthetic_control/#1-overview)).
```{mermaid}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#EBF5FB','edgeLabelBackground':'#fff','fontSize':'14px'}}}%%
flowchart TD
A["<b>Treated unit</b><br/>(e.g. Portugal)"]:::treated
B["<b>Donor pool</b><br/>(11 other markets)"]:::donor
A --> C["Pre-treatment<br/>predictors X₁, Z₁"]:::step
B --> D["Pre-treatment<br/>predictors X₀, Z₀"]:::step
C --> E["<b>Inner optimisation</b><br/>Solve W* given V"]:::opt
D --> E
E --> F["<b>Outer optimisation</b><br/>Solve V* that minimises<br/>pre-treatment MSPE"]:::opt
F --> G["<b>Optimal weights W*</b><br/>sparse, non-negative, sum to 1"]:::synth
B --> G
G --> I["Synthetic control<br/>= Σ wⱼ · Yⱼₜ<br/>(counterfactual path)"]:::counter
A --> H["Actual post-treatment path"]:::actual
H --> J["<b>Gap = ATT estimate</b>"]:::att
I --> J
classDef treated fill:#E05C3A,color:#fff,stroke:none
classDef donor fill:#5B9BD5,color:#fff,stroke:none
classDef step fill:#B0C4DE,color:#1a1a2e,stroke:#8fa8c8
classDef opt fill:#B0C4DE,color:#1a1a2e,stroke:#8fa8c8
classDef synth fill:#5B9BD5,color:#fff,stroke:none
classDef actual fill:#E05C3A,color:#fff,stroke:none
classDef counter fill:#f5f5f5,color:#333,stroke:#aaa
classDef att fill:#00B4A6,color:#fff,stroke:none
```
## Trajectory Matching: What SCM Actually Does
Imagine a graph with time on the horizontal axis and the outcome on the vertical. Before treatment, you see two lines: the solid red line of Portugal's actual trajectory, and the dashed blue line of its synthetic counterpart. In the pre-treatment period, these two lines are nearly indistinguishable, they move together, dip together, recover together. This is not coincidence; it is the optimisation at work.
Then, at the treatment date, the lines diverge. Portugal's actual trajectory continues upward while the synthetic trajectory follows the path that Portugal *would have taken* in the absence of treatment. The vertical distance between the two lines, the **gap**, is the estimated causal effect at each point in time.
This is trajectory matching in its purest form. Unlike DiD, which matches on *average trends* (a single number), SCM matches on the *entire pre-treatment path*. If the synthetic unit reproduces Portugal's trajectory through booms, recessions, seasonal fluctuations, and idiosyncratic shocks, then its post-treatment path is a far more credible counterfactual than any simple before-after comparison. Huntington-Klein (2021) frames SCM as "a variant of difference-in-differences with matching," but with key distinctions: the matching is done on pre-treatment *outcomes* rather than treatment propensity; it requires many pre-treatment periods; and inference uses randomisation inference (placebo tests) rather than conventional standard errors.
## The Convex Hull: Why Weights Must Be Non-Negative
The restriction $w_j \geq 0,\ \sum_j w_j = 1$ is not a mere technical detail, it is the **defining feature** that separates SCM from regression. Abadie (2020, Section 5) explains:
> *"The synthetic control method guards against extrapolation outside the convex hull of the data because weights from all control units can be chosen to be positive and sum to one."*
Geometrically, the **convex hull** is the polytope formed by the characteristics of donor pool units. If the treated unit falls *inside* this hull, the SCM finds an exact match. If it falls *outside*, the SCM projects it onto the hull boundary, and transparently signals an imperfect fit. The convex hull constraint ensures that the synthetic control is always a plausible unit, one that could actually exist given the data you observe.
::: {.callout-note}
## Regression vs. Synthetic Control: a hidden difference
Regression is *also* a weighting estimator, its weights sum to one when an intercept is included. The critical difference is that regression weights are **unconstrained**: they can be negative, and they can exceed one. This means regression can extrapolate outside the observed data without any warning.
Abadie, Diamond & Hainmueller (2015) demonstrate this concretely in their study of German reunification. When they estimate the effect using OLS, the implied weights include Greece at −0.09, Italy at −0.05, and Portugal at −0.08. Negative weights mean the regression is constructing a counterfactual that no combination of actual countries could produce. The SCM, by contrast, assigns positive weights only to Austria (0.42), the USA (0.22), Japan (0.16), Switzerland (0.11), and the Netherlands (0.09). Every weight corresponds to a real country.
The SCM's non-negativity constraint is not a restriction, it is a guard against invisible extrapolation. Note, however, that SCM avoids extrapolation in *observed* covariate space, while identification still relies on the underlying latent factor structure. A synthetic unit inside the convex hull is a plausible unit, but plausibility does not guarantee that the latent factor model holds.
:::
```{r fig-convexhull}
#| label: scm-fig-convex-hull
#| fig-cap: "Geometric representation of the convex hull (2D simplified). The SCM only interpolates, it never extrapolates in observed covariate space. OLS regression, by contrast, allows negative weights and extrapolation outside the observed data support."
set.seed(7)
ctrl_pts <- data.frame(
x = c(2.1, 4.5, 6.8, 7.2, 5.9, 3.3, 1.5, 4.1),
y = c(3.0, 1.2, 2.5, 5.1, 6.8, 7.0, 5.2, 4.5),
label = paste0("Control ", LETTERS[1:8])
)
portugal_pt <- data.frame(x = 4.2, y = 4.0, label = "Portugal (actual)")
synth_pt <- data.frame(x = 4.0, y = 3.7, label = "Portugal Synthetic")
extrap_pt <- data.frame(x = 8.8, y = 7.8, label = "OLS extrapolation\n(negative weights)")
hull_idx <- chull(ctrl_pts$x, ctrl_pts$y)
hull_pts <- ctrl_pts[c(hull_idx, hull_idx[1]), ]
ggplot() +
geom_polygon(data = hull_pts, aes(x = x, y = y),
fill = "#ADE8F4", alpha = 0.35, colour = "#023E8A", linewidth = 0.8) +
geom_point(data = ctrl_pts, aes(x = x, y = y), size = 4, colour = "#0077B6") +
geom_text(data = ctrl_pts, aes(x = x, y = y, label = label),
nudge_y = 0.28, size = 3, colour = "#0077B6") +
geom_point(data = portugal_pt, aes(x = x, y = y),
size = 5, colour = "#D62728", shape = 17) +
geom_text(data = portugal_pt, aes(x = x, y = y, label = label),
nudge_y = -0.35, size = 3.5, colour = "#D62728", fontface = "bold") +
geom_point(data = synth_pt, aes(x = x, y = y),
size = 6, colour = "#EE9B00", shape = 8, stroke = 1.5) +
geom_text(data = synth_pt, aes(x = x, y = y, label = label),
nudge_y = 0.35, size = 3.5, colour = "#EE9B00", fontface = "bold") +
geom_point(data = extrap_pt, aes(x = x, y = y),
size = 5, colour = "#9B2226", shape = 4, stroke = 2) +
geom_text(data = extrap_pt, aes(x = x, y = y, label = label),
nudge_y = -0.5, size = 3, colour = "#9B2226") +
annotate("label", x = 4.3, y = 1.0,
label = "Safe zone: synthetic in convex hull",
size = 3, fill = "#ADE8F4", colour = "#023E8A") +
labs(
title = "Convex Hull of the Donor Pool: the frontier of safe interpolation",
subtitle = "W* ≥ 0 and Σw = 1 prevent extrapolation beyond observed data",
x = "Pre-treatment characteristic 1 (e.g., GDP per capita)",
y = "Pre-treatment characteristic 2 (e.g., digital penetration rate)"
)
```
```{r tbl-weights}
#| label: scm-tbl-weights
#| tbl-cap: "Hypothetical example of synthetic weights. Non-negative weights summing to 1 ensure the synthetic control lies within the convex hull of the donor pool. Only units with positive weight are shown individually."
weights_ex <- data.frame(
Country = c("Spain", "Italy", "Greece", "Czech Republic",
"Remaining 8 units (Weight = 0.000)"),
Weight = c(0.35, 0.28, 0.19, 0.18, 0.00),
check.names = FALSE
)
kable(weights_ex, booktabs = TRUE, digits = 2, align = "lr") |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE, font_size = 12) |>
column_spec(2, bold = TRUE, color = "#005F73") |>
row_spec(5, italic = TRUE, color = "grey50") |>
add_footnote(
"These weights are hypothetical. In practice they are determined by constrained numerical optimisation (MSPE minimisation). Weights are algorithmic outputs of the interpolation geometry and should not be subject to post-hoc qualitative rationalisation, e.g. 'Spain has the highest weight because of Iberian market similarities' confuses the geometry of fit with substantive similarity.",
notation = "none"
)
```
# Formal Setup {#sec-formal}
## The Factor Model Justification
Following Abadie et al. (2010, JASA), suppose we observe $J + 1$ units over $T$ periods. Only unit $i = 1$ is treated, from period $T_0 + 1$ onward. The **potential outcome without treatment** follows a **linear factor model**:
$$
Y_{it}^{(0)} = \delta_t + \boldsymbol{\theta}_t' \mathbf{Z}_i + \boldsymbol{\lambda}_t' \boldsymbol{\mu}_i + \varepsilon_{it}
\tag{1}
$$
| Symbol | Meaning |
|--------|---------|
| $\delta_t$ | Common time trend |
| $\mathbf{Z}_i$ | Observed covariates unaffected by treatment |
| $\boldsymbol{\theta}_t$ | Time-varying coefficients on observed covariates |
| $\boldsymbol{\lambda}_t$ | **Time-varying** unobserved common factors |
| $\boldsymbol{\mu}_i$ | Unit-specific factor loadings |
| $\varepsilon_{it}$ | Transitory shocks with zero mean |
**Critical point:** The standard DiD model is a special case of (1) with $\boldsymbol{\lambda}_t = \boldsymbol{\lambda}$ (constant over time). In that case, $\boldsymbol{\lambda}' \boldsymbol{\mu}_i$ reduces to a unit fixed effect $\alpha_i$, and differencing removes it, that is the parallel trends assumption in action. SCM is more general: it allows unobserved confounder effects to **vary over time**, making simple differencing impossible. This is why DiD with violated parallel trends is not merely inconvenient, it is fundamentally identified off the wrong model.
The diagram below illustrates how DiD and SCM differ in the assumptions they make about the factor structure.
```{mermaid}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#F8F9FA','fontSize':'13px'}}}%%
flowchart LR
subgraph DiD["DiD, Restricted Factor Model"]
direction TB
A["λₜ = λ (constant)"] --> B["λ'μᵢ = αᵢ (unit FE)"]
B --> C["Differencing removes αᵢ"]
C --> D["Requires: parallel trends"]
end
subgraph SCM["SCM, General Factor Model"]
direction TB
E["λₜ varies over time"] --> F["λₜ'μᵢ unobservable per period"]
F --> G["Matched via pre-treatment trajectory"]
G --> H["Requires: good pre-treatment fit<br/> + long T0"]
end
DiD ~~~ SCM
style DiD fill:#FFF3CD,stroke:#F0AD4E,color:#000
style SCM fill:#D4EDDA,stroke:#28A745,color:#000
```
## The SCM Estimator
The goal is to estimate the counterfactual $Y_{1t}^{(0)}$ for the treated unit post-treatment:
$$
\hat{Y}_{1t}^{(0)} = \sum_{j=2}^{J+1} w_j^* \cdot Y_{jt}
$$
The optimal weights $\mathbf{W}^* = (w_2^*, \ldots, w_{J+1}^*)'$ solve:
$$
\mathbf{W}^* = \arg\min_{\mathbf{W}} \left(\mathbf{X}_1 - \mathbf{X}_0 \mathbf{W}\right)' \mathbf{V} \left(\mathbf{X}_1 - \mathbf{X}_0 \mathbf{W}\right)
\tag{2}
$$
subject to $w_j \geq 0$ and $\sum_{j=2}^{J+1} w_j = 1$, where $\mathbf{X}_1$ is the $(k \times 1)$ vector of pre-treatment characteristics for the treated unit, $\mathbf{X}_0$ is the $(k \times J)$ matrix of the same characteristics for controls, and $\mathbf{V}$ is a diagonal positive-definite matrix weighting the importance of each predictor.
The **treatment effect** at each post-treatment period — i.e., the ATT at time $t$ — is:
$$
\hat{\alpha}_{1t} = Y_{1t} - \hat{Y}_{1t}^{(0)} = Y_{1t} - \sum_{j=2}^{J+1} w_j^* Y_{jt}, \quad t > T_0
\tag{3}
$$
The matrix $\mathbf{V}$ is chosen to minimise the Mean Squared Prediction Error (MSPE) in the pre-treatment period:
$$
\mathbf{V}^* = \arg\min_{\mathbf{V}} \sum_{t=1}^{T_0} \left(Y_{1t} - \sum_{j=2}^{J+1} \hat{w}_j(\mathbf{V}) \cdot Y_{jt}\right)^2
\tag{4}
$$
This creates a nested optimisation: for each candidate $\mathbf{V}$, solve (2) for $\mathbf{W}^*(\mathbf{V})$; then select the $\mathbf{V}$ producing the best pre-treatment fit. The `Synth` package implements this via `optim()` (BFGS or Nelder-Mead) at the outer level and `ipop()` (from `kernlab`) at the inner level [@adh2011].
::: {.callout-tip}
## Why sparsity of weights is a feature, not a bug
The number of non-zero weights is bounded above by the number of predictors $k$. With four predictors, at most four donor units will receive positive weight. This sparsity is a geometric property of convex hull interpolation, not an artificially imposed penalty — a sparse solution means the synthetic control is anchored to a small number of structurally similar donors, not an average of everything.
If all weights are approximately $1/J$, that is a red flag: the synthetic control is indistinguishable from a simple donor mean, and you are gaining nothing over naive DiD.
:::
Abadie et al. [-@abadie2010] show that if $\mathbf{W}^*$ satisfies:
$$
\sum_{j=2}^{J+1} w_j^* Y_{jt} = Y_{1t} \quad \forall\, t \leq T_0, \qquad \sum_{j=2}^{J+1} w_j^* \mathbf{Z}_j = \mathbf{Z}_1
$$
then under the factor model the bias of $\hat{\alpha}_{1t}$ is bounded by a term that decreases with $T_0$. More pre-treatment periods yield a tighter bias bound — this is why SCM requires long pre-treatment series and is not recommended when $T_0$ is small.
::: {.callout-important}
## Minimum pre-treatment periods
The bias bound shrinks with $T_0$. As a practical guideline: with fewer than **10 pre-treatment periods**, proceed with caution and run extensive robustness checks; with fewer than **5**, switch to ASCM or accept that causal attribution is not achievable with the available data.
This is a rule of thumb, not a sharp identification condition. A short $T_0$ with perfect pre-treatment fit may still be credible; a long $T_0$ with poor fit is not.
:::
# When SCM Works vs. When It Fails {#sec-works-fails}
## Requirements for a Valid Application
The diagram below maps the conditions under which SCM is valid, problematic, or should be replaced by an alternative.
```{mermaid}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#EBF5FB'}}}%%
flowchart TD
A["Do you have a single treated unit?"] -->|Yes| B["Is T0 ≥ 10?"]
A -->|"No (multiple units)"| Z1["→ Consider SDID<br/>or staggered DiD"]
B -->|Yes| C["Is pre-treatment MSPE low?<br/>(synthetic tracks treated trajectory)"]
B -->|No| Z2["→ SCM bias may be high.<br/>Use ASCM or report<br/>with extreme caution"]
C -->|"Yes, excellent fit"| D["Is treated unit inside<br/>convex hull of donor pool?"]
C -->|"No, poor fit"| Z3["→ ASCM with ridge correction<br/>(Ben-Michael et al. 2021)"]
D -->|Yes| E["Classic SCM: proceed<br/>Run both placebo tests"]
D -->|"No (outlier unit)"| Z4["→ ASCM or flag clearly<br/>SCM will extrapolate<br/>to hull boundary"]
style E fill:#D4EDDA,stroke:#28A745
style Z1 fill:#FFF3CD,stroke:#F0AD4E
style Z2 fill:#F8D7DA,stroke:#DC3545
style Z3 fill:#FFF3CD,stroke:#F0AD4E
style Z4 fill:#FFF3CD,stroke:#F0AD4E
```
## Overfitting Risk
When the number of pre-treatment periods ($T_0$) is small relative to the number of donor pool units ($J$), the SCM can overfit: it finds weights that match the pre-treatment trajectory by chance rather than by capturing genuine structural similarity. A synthetic unit that perfectly tracks the treated unit through three pre-treatment periods tells you almost nothing, any random combination of three donors can match three points.
## Rich Donor Pool Construction
SCM requires a donor pool with sufficient variety to construct the synthetic unit. Abadie et al. (2015) recommend three exclusion criteria:
1. Remove units affected by the same event as the treated unit (contaminated controls).
2. Remove units with large idiosyncratic shocks during the analysis window.
3. Restrict to units similar enough to the treated unit to avoid interpolation bias.
::: {.callout-warning}
## Donor pool construction matters more than the optimisation
The weights are only as good as the pool they draw from. Including a unit affected by the same treatment, or a correlated structural shock, biases the synthetic control toward zero, attenuating your estimate. A bad answer to the question "how did you build your donor pool?" invalidates the entire analysis, regardless of how sophisticated the weight optimisation is.
Do not answer this question with "we included all available units." Convenience is not a validity criterion.
:::
# Inference: The Fundamental Problem with N = 1 {#sec-inference}
Conventional standard errors are inapplicable with a single treated unit. Abadie et al. (2010) propose **permutation (placebo) tests** as the only principled inferential tool in this setting.
## In-Space Placebo Test
The procedure: iteratively apply SCM to each unit in the donor pool as if it were treated, collect the "placebo gaps," and compare the true gap against this null distribution.
The exact permutation p-value is:
$$
p = \frac{1}{J+1} \sum_{j=1}^{J+1} \mathbf{1}\left(r_j \geq r_1\right), \quad r_j = \frac{\text{MSPE}_{\text{post}}^{(j)}}{\text{MSPE}_{\text{pre}}^{(j)}}
\tag{5}
$$
where $r_j$ is the ratio of post-treatment MSPE to pre-treatment MSPE for unit $j$. This ratio penalises placebos with poor pre-treatment fit, which would otherwise trivially produce large post-treatment gaps. If the treated unit's ratio is the largest in the distribution, the exact p-value is $1/(J+1)$, the smallest achievable given the sample size.
::: {.callout-important}
## Interpreting the permutation p-value correctly
The permutation p-value is *not* equivalent to a classical asymptotic p-value. It answers: "In how many permutations does the placebo gap ratio exceed the real gap ratio?" An exact p-value of $1/39 \approx 0.026$ means only 1 of 39 permutations produces a gap as large as the one observed for California, this constitutes evidence against the null of no effect.
Do not say "significant at the 5% level" in the classical sense. Say instead: "the post-treatment gap for the treated unit is larger than all placebo gaps in the distribution," or "the permutation p-value is $1/(J+1)$, providing evidence against the null of no effect."
Note also that $1/(J+1)$ is the *floor* of achievable p-values given a donor pool of size $J$. With a small donor pool, this limits the resolution of your test, another reason a large, carefully constructed donor pool is valuable.
:::
## In-Time Placebo Test
A mandatory companion is the **in-time placebo**: simulate the treatment several years early (e.g., in 1980 instead of 1989). If the SCM is sound, the gap after this fictitious treatment date should be approximately zero. A substantively large "effect" before the real treatment signals overfitting or pre-existing confounders, in which case the post-treatment gap cannot be credibly attributed to the treatment.
::: {.callout-warning}
## Never present SCM results without running both placebo tests
An in-space placebo test tells you whether your gap is unusual relative to a null distribution. An in-time placebo test tells you whether the pre-treatment fit is genuinely capturing structural similarity or merely fitting noise. Running only one gives you half the picture. A result that passes one but fails the other should not be presented as credible evidence.
This is not a suggestion, it is the minimum standard for an SCM analysis to be taken seriously.
:::
# Example: California Proposition 99 {#sec-example}
The benchmark application in the SCM literature is Abadie et al. (2010)'s study of **Proposition 99**, California's 1988 tobacco control program. California raised cigarette taxes by 25 cents per pack and funded anti-smoking media campaigns. The question: how many packs per capita did Proposition 99 actually prevent?
The setting is a perfect SCM case: one treated unit (California), a rich donor pool (38 other US states), and a long pre-treatment period (1970–1988). California was already on a different trajectory from the average state, higher taxes, stronger anti-smoking sentiment, and a steeper pre-existing decline. The simple donor average is a poor counterfactual. This is exactly the setting where DiD breaks and SCM shines.
## Data Preparation
```{r dataprep}
#| label: scm-dataprep
for (p in c("Synth", "tidysynth")) {
if (!requireNamespace(p, quietly = TRUE))
install.packages(p, repos = "https://cloud.r-project.org", quiet = TRUE)
}
invisible(suppressWarnings(suppressPackageStartupMessages({
library(Synth)
library(tidysynth)
})))
data("smoking", package = "tidysynth")
smoking <- as.data.frame(smoking)
smoking$state_id <- as.numeric(factor(smoking$state, levels = sort(unique(smoking$state))))
ca_id <- unique(smoking$state_id[smoking$state == "California"])
control_ids <- setdiff(unique(smoking$state_id), ca_id)
invisible(capture.output(
dataprep_out <- dataprep(
foo = smoking,
predictors = c("lnincome", "retprice", "age15to24", "beer"),
predictors.op = "mean",
time.predictors.prior = 1980:1988,
special.predictors = list(
list("cigsale", 1975, "mean"),
list("cigsale", 1980, "mean"),
list("cigsale", 1988, "mean")
),
dependent = "cigsale",
unit.variable = "state_id",
unit.names.variable = "state",
time.variable = "year",
treatment.identifier = ca_id,
controls.identifier = control_ids,
time.optimize.ssr = 1970:1988,
time.plot = 1970:2000
)
))
```
## Estimation
```{r synth-est}
#| label: scm-synth-est
synth_out <- synth(data.prep.obj = dataprep_out, method = "BFGS")
synth_tables <- synth.tab(dataprep.res = dataprep_out, synth.res = synth_out)
```
The synthetic California is built from a handful of states that reproduce California's pre-1989 profile. Most states receive zero weight, they are too dissimilar to contribute. This sparsity is a natural geometric property of convex hull interpolation, bounded by the number of predictors $k = 7$ (4 averaged predictors plus 3 special predictors for cigsale at 1975, 1980, and 1988).
```{r tbl-balance}
#| label: scm-tbl-balance
#| tbl-cap: "Pre-treatment balance: California actual vs. California Synthetic vs. 38-state donor mean. The synthetic control replicates California's pre-1989 characteristics far better than the simple donor average."
synth_tables$tab.pred |>
kable(booktabs = TRUE, digits = 2,
col.names = c("California (actual)", "California Synthetic", "38-state mean")) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
```
```{r tbl-weights-ca}
#| label: scm-tbl-weights-ca
#| tbl-cap: "Synthetic weights W* for California. Only states with weight > 0.001 are shown individually. Sparsity, most states receiving zero weight, is a geometric property of the convex hull, not an artificially imposed constraint."
synth_tables$tab.w |>
dplyr::filter(w.weights > 0.001) |>
dplyr::arrange(desc(w.weights)) |>
kable(booktabs = TRUE, digits = 3,
col.names = c("Weight W*", "State", "State No.")) |>
kable_styling(bootstrap_options = c("striped"), full_width = FALSE) |>
column_spec(1, bold = TRUE, color = "#005F73") |>
add_footnote(
"These weights are the output of MSPE minimisation. Their interpretation is geometric (which donors minimise pre-treatment fit error) rather than substantive (which donors are 'most similar' to California in some qualitative sense).",
notation = "none"
)
```
## Path Plot and Gap Plot
```{r fig-pathplot}
#| label: scm-fig-pathplot
#| fig-cap: "Per-capita cigarette sales: California actual vs. California Synthetic, 1970–2000. The pre-1989 lines are nearly indistinguishable, the synthetic unit reproduces California's trajectory through economic cycles and seasonal patterns. The post-1988 divergence estimates the causal effect of Proposition 99."
years_ca <- 1970:2000
y_actual <- as.numeric(dataprep_out$Y1plot)
y_synthetic <- as.numeric(dataprep_out$Y0plot %*% synth_out$solution.w)
df_path <- data.frame(
year = rep(years_ca, 2),
packs = c(y_actual, y_synthetic),
serie = rep(c("California (actual)", "California Synthetic"), each = length(years_ca))
)
ggplot(df_path, aes(x = year, y = packs, colour = serie, linetype = serie)) +
geom_line(linewidth = 1.2) +
geom_vline(xintercept = 1989, linetype = "dashed", colour = "grey30", linewidth = 0.8) +
annotate("text", x = 1989.3, y = 130, label = "Proposition 99\n(Jan 1989)",
hjust = 0, size = 3.3, colour = "grey30") +
scale_colour_manual(values = c(
"California (actual)" = "#D62728",
"California Synthetic" = "#1F77B4"
)) +
scale_linetype_manual(values = c("solid", "longdash")) +
labs(
title = "Effect of Proposition 99 on Cigarette Sales",
subtitle = "Synthetic Control Method, replication of Abadie, Diamond & Hainmueller (2010)",
x = "Year", y = "Per capita cigarette sales (packs)",
colour = NULL, linetype = NULL
) +
theme(legend.position = "bottom")
```
```{r fig-gapplot}
#| label: scm-fig-gapplot
#| fig-cap: "Gap (actual minus synthetic California). Pre-1989: gap near zero confirms good pre-treatment fit. Post-1989: growing negative gap represents the causal reduction in cigarette sales attributable to Proposition 99. The shaded region marks the cumulative treatment effect."
df_gap <- data.frame(year = years_ca, gap = y_actual - y_synthetic)
effect_2000 <- round(df_gap$gap[df_gap$year == 2000], 1)
ggplot(df_gap, aes(x = year, y = gap)) +
geom_hline(yintercept = 0, linetype = "dashed", colour = "grey50") +
geom_vline(xintercept = 1989, linetype = "dashed", colour = "grey30", linewidth = 0.8) +
geom_ribbon(data = subset(df_gap, year >= 1989),
aes(ymin = gap, ymax = 0), fill = "#9B2226", alpha = 0.15) +
geom_line(linewidth = 1.2, colour = "#005F73") +
geom_point(size = 2, colour = "#005F73") +
annotate("text", x = 2000.2, y = effect_2000 - 1,
label = paste0(effect_2000, " packs\nper capita"),
hjust = 0, size = 3.2, colour = "#9B2226") +
labs(
title = "Estimated Causal Gap: California − California Synthetic",
subtitle = "Pre-1989: gap ≈ 0 (good fit). Post-1989: growing causal effect.",
x = "Year", y = "Gap in per capita sales (packs)"
) +
scale_x_continuous(breaks = seq(1970, 2000, 5))
```
## In-Space Placebo Test
```{r placebo-test}
#| label: scm-placebo-test
#| results: hide
store_gaps <- matrix(NA, nrow = length(1970:2000), ncol = length(control_ids))
colnames(store_gaps) <- paste0("s", control_ids)
for (i in seq_along(control_ids)) {
ctrl_i <- control_ids[i]
donor_i <- setdiff(control_ids, ctrl_i)
tryCatch({
invisible(capture.output({
dp_i <- dataprep(
foo = smoking,
predictors = c("lnincome", "retprice", "age15to24", "beer"),
predictors.op = "mean",
time.predictors.prior = 1980:1988,
special.predictors = list(
list("cigsale", 1975, "mean"),
list("cigsale", 1980, "mean"),
list("cigsale", 1988, "mean")
),
dependent = "cigsale",
unit.variable = "state_id",
unit.names.variable = "state",
time.variable = "year",
treatment.identifier = ctrl_i,
controls.identifier = donor_i,
time.optimize.ssr = 1970:1988,
time.plot = 1970:2000
)
so_i <- synth(data.prep.obj = dp_i, method = "BFGS", Sigf.ipop = 3)
}))
store_gaps[, i] <- dp_i$Y1plot - (dp_i$Y0plot %*% so_i$solution.w)
}, error = function(e) NULL)
}
```
```{r fig-placebo}
#| label: scm-fig-placebo
#| fig-cap: "In-space placebo test: California gap (blue) vs. distribution of placebo gaps (grey). Only placebos with pre-treatment MSPE less than 5 times California's are shown, donors with poor pre-treatment fit would trivially produce large post-treatment gaps and are not informative about the null distribution. California's gap stands clearly outside the placebo cloud post-1989."
years_num <- 1970:2000
pre_idx <- years_num < 1989
mse_pre <- apply(store_gaps[pre_idx, ], 2, function(x) mean(x^2, na.rm = TRUE))
ca_mse <- mean((y_actual - y_synthetic)[pre_idx]^2)
keep <- which(mse_pre < 5 * ca_mse)
df_placebo <- as.data.frame(store_gaps[, keep]) |>
dplyr::mutate(year = years_num) |>
tidyr::pivot_longer(-year, names_to = "state", values_to = "gap") |>
dplyr::filter(!is.na(gap))
ca_gap_df <- data.frame(year = years_num, gap = y_actual - y_synthetic)
ggplot() +
geom_line(data = df_placebo,
aes(x = year, y = gap, group = state),
colour = "grey65", alpha = 0.55, linewidth = 0.45) +
geom_hline(yintercept = 0, linetype = "dashed", colour = "grey40") +
geom_vline(xintercept = 1989, linetype = "dashed", colour = "grey30", linewidth = 0.8) +
geom_line(data = ca_gap_df, aes(x = year, y = gap),
colour = "#005F73", linewidth = 1.6) +
annotate("text", x = 1972, y = min(ca_gap_df$gap, na.rm = TRUE) + 3,
label = "California", colour = "#005F73", fontface = "bold", size = 3.5) +
annotate("text", x = 1989.5, y = 40,
label = "Prop. 99", hjust = 0, size = 3, colour = "grey30") +
labs(
title = "Placebo Test: Permutation Inference",
subtitle = paste0("California gap vs. distribution of ", length(keep),
" placebos (MSPE_pre < 5× CA). p = 1/39 ≈ 0.026."),
x = "Year", y = "Gap in per capita sales (packs)"
) +
scale_x_continuous(breaks = seq(1970, 2000, 5))
```
Abadie et al. (2010) report an exact permutation p-value of $1/39 \approx 0.026$, California's post-treatment MSPE ratio is larger than that of any other state. This means: in every permutation of the placebo test, California produces the largest gap ratio. This constitutes evidence against the null of no effect, using the only valid inferential framework available with a single treated unit.
## In-Time Placebo Test
```{r in-time-placebo}
#| label: scm-fig-in-time-placebo
#| results: hide
invisible(capture.output({
dp_placebo_time <- dataprep(
foo = smoking,
predictors = c("retprice", "age15to24"),
predictors.op = "mean",
time.predictors.prior = 1970:1979,
special.predictors = list(
list("cigsale", 1970, "mean"),
list("cigsale", 1975, "mean"),
list("cigsale", 1979, "mean")
),
dependent = "cigsale",
unit.variable = "state_id",
unit.names.variable = "state",
time.variable = "year",
treatment.identifier = ca_id,
controls.identifier = control_ids,
time.optimize.ssr = 1970:1979,
time.plot = 1970:1988
)
so_pt <- synth(data.prep.obj = dp_placebo_time, method = "BFGS", Sigf.ipop = 3)
}))
y_pt_actual <- as.numeric(dp_placebo_time$Y1plot)
y_pt_synth <- as.numeric(dp_placebo_time$Y0plot %*% so_pt$solution.w)
anos_pt <- 1970:1988
```
```{r plot-in-time}
#| label: scm-plot-in-time
#| fig-cap: "In-time placebo: fictitious treatment in 1980 (9 years before Proposition 99). The two series remain close after 1980, no evidence of anticipation effects or pre-existing confounders. A large gap here would have undermined the credibility of the 1989 estimate."
df_pt2 <- data.frame(
year = rep(anos_pt, 2),
packs = c(y_pt_actual, y_pt_synth),
serie = rep(c("California (actual)", "California Synthetic"), each = length(anos_pt))
)
ggplot(df_pt2, aes(x = year, y = packs, colour = serie, linetype = serie)) +
geom_line(linewidth = 1.2) +
geom_vline(xintercept = 1980, linetype = "dashed", colour = "#9B2226") +
annotate("text", x = 1980.2, y = 105,
label = "Placebo treatment\n(fictitious)", hjust = 0,
colour = "#9B2226", size = 3.2) +
scale_colour_manual(values = c("California (actual)" = "#D62728",
"California Synthetic" = "#1F77B4")) +
scale_linetype_manual(values = c("solid", "longdash")) +
labs(
title = "In-Time Placebo: Fictitious Treatment in 1980",
subtitle = "Series should track closely after 1980 if no pre-existing confounders or anticipation",
x = "Year", y = "Per capita cigarette sales (packs)",
colour = NULL, linetype = NULL
) +
theme(legend.position = "bottom")
```
The two series remain close after the fictitious 1980 treatment date. This is the expected outcome under a valid SCM, it confirms that the pre-treatment fit reflects genuine structural similarity, not overfitting to a short window.
# The Evolution: ASCM and SDID {#sec-evolution}
The classic SCM is the right tool when pre-treatment fit is excellent and you have a single treated unit. But two natural extensions address its limitations.
## When SCM Fails: The Imperfect Fit Problem
SCM's convex hull constraint is its greatest strength, no extrapolation in observed covariate space, but also its greatest limitation. When the treated unit is an outlier relative to the donor pool, no convex combination of donors can reproduce the treated unit's pre-treatment trajectory. The fit will be poor, and the resulting counterfactual will be biased. Abadie (2020) is explicit: if the pre-treatment fit is poor, you should not use SCM.
## Augmented SCM: Ridge-Corrected Bias Reduction
Ben-Michael, Feller & Rothstein (2021) propose the **Augmented Synthetic Control Method (ASCM)**, which augments the standard SCM estimator with an outcome model that corrects for the bias from imperfect pre-treatment fit. The key idea: if the SCM counterfactual has a residual error in the pre-treatment period, fit an outcome model to those residuals and use it to adjust the post-treatment prediction.
Formally, the ASCM estimator is:
$$
\hat{Y}_{1t}^{(0),\text{ASCM}} = \underbrace{\sum_{j=2}^{J+1} w_j^* Y_{jt}}_{\text{SCM counterfactual}} + \underbrace{\hat{m}\left(\mathbf{X}_1 - \sum_{j=2}^{J+1} w_j^* \mathbf{X}_j\right)}_{\text{outcome model correction}}
$$
where $\hat{m}(\cdot)$ is an outcome model, typically ridge regression, fitted on the donor pool. The argument to $\hat{m}$ is the *interpolation residual*: the difference between the treated unit's characteristics and the synthetic unit's characteristics. If the SCM fit is perfect, this residual is zero and the correction vanishes, ASCM reduces to SCM exactly. If the fit is imperfect, the outcome model adjusts the counterfactual in a controlled manner.
The **bias-variance tradeoff** is central here:
- **Low regularisation:** The outcome model can extrapolate aggressively, reducing bias from the convex hull constraint but increasing variance and the risk of overfitting. In the limit, ASCM approaches unconstrained OLS regression.
- **High regularisation:** The outcome model shrinks toward zero, providing minimal correction. ASCM approaches standard SCM.
- **Optimal regularisation:** Chosen via cross-validation on the pre-treatment period. The `augsynth` R package automates this.
::: {.callout-note}
## ASCM vs. SDID: addressing different problems
ASCM and SDID both extend classic SCM, but they address different sources of bias through different mechanisms.
**ASCM** corrects bias through an **outcome model** (ridge regression on interpolation residuals) applied *after* the SCM weights are computed. It is designed for the case where the treated unit falls partially outside the convex hull.
**SDID** corrects bias through **time weights** and **unit fixed effects** applied *during* the weight construction itself. It is designed for settings with multiple treated units, staggered treatment, or the need for asymptotic standard errors.
Confusing the two is a category error. If you have a single treated unit with imperfect pre-treatment fit, use ASCM. If you have multiple treated units and need valid asymptotic inference, use SDID.
:::
## Synthetic Difference-in-Differences: Dual Weighting
Synthetic Difference-in-Differences (SDID), introduced by Arkhangelsky, Athey, Hirshberg, Imbens & Wager (2021), is the direct intellectual descendant of both DiD and SCM. It makes two modifications to classic SCM: it adds **unit fixed effects** (making the estimator invariant to additive unit-level shifts, as DiD does via differencing), and introduces **time weights** $\hat{\lambda}_t$ that balance pre- and post-treatment periods to remove bias from atypical time periods.
The estimator solves:
$$
(\hat{\tau}^{sdid}, \hat{\mu}, \hat{\alpha}, \hat{\beta}) = \arg\min_{\tau,\mu,\alpha,\beta} \sum_{i=1}^{N} \sum_{t=1}^{T} \hat{\omega}_i \hat{\lambda}_t \left(Y_{it} - \mu - \alpha_i - \beta_t - W_{it}\tau\right)^2
\tag{6}
$$
where $W_{it}$ is the treatment indicator, the unit weights $\hat{\omega}_i$ are analogous to SCM weights (optimised for pre-treatment balance, with a regularisation parameter $\zeta = (N_{tr} T_{post})^{1/4} \hat{\sigma}$ that prevents weight concentration), and the time weights $\hat{\lambda}_t$ down-weight pre-treatment periods that are atypical relative to the post-treatment average.
::: {.callout-note}
## Dual weighting is not "double robustness"
SDID employs dual weighting over both units and time periods, providing robustness that neither DiD nor SCM alone can offer: it remains consistent when *either* the DiD parallel trends assumption *or* the SCM interpolation condition holds. If parallel trends are valid, SDID reduces to something close to DiD with reweighted controls. If SCM's assumptions hold, SDID reduces to something close to SCM with time adjustments.
However, this is **not** "double robustness" in the semiparametric sense (where consistency requires correct specification of *either* the outcome model *or* the propensity score). SDID's robustness comes from a different mechanism: weighting on two dimensions. The distinction matters because "double robustness" carries specific theoretical connotations that do not apply here.
:::
Arkhangelsky et al. (2021) prove asymptotic normality of $\hat{\tau}^{sdid}$, enabling inference via jackknife or bootstrap, unlike classic SCM which must rely on permutation tests with their granularity floor of $1/(J+1)$.
## Structural Comparison
```{r tbl-comparison}
#| label: scm-tbl-comparison
#| tbl-cap: "Structural comparison of causal estimators for aggregate panels. DiD = Difference-in-Differences; SCM = Synthetic Control Method; ASCM = Augmented SCM; SDID = Synthetic Difference-in-Differences; MSPE = Mean Squared Prediction Error; ATT = Average Treatment Effect on the Treated; FE = Fixed Effects."
comp <- data.frame(
Estimator = c("DiD", "SCM", "ASCM", "SDID"),
Unit_Weights = c("Uniform (1/N)", "Optimised (convex)", "Optimised + ridge", "Optimised + intercept"),
Time_Weights = c("Uniform", "Uniform", "Uniform", "Optimised"),
Unit_FE = c("Yes", "No", "No", "Yes"),
Extrapolation= c("Allowed", "Prohibited (convex hull)", "Controlled (ridge penalty)", "Regularised"),
Inference = c("Asymptotic (cluster SE)", "Permutation tests", "Conformal inference", "Asymptotic (normality)"),
When_to_Use = c(
"Many units, parallel trends plausible",
"1 treated unit, excellent pre-fit, long T₀",
"1 treated unit, imperfect pre-fit",
"Multiple units/periods, dual weighting desired"
),
check.names = FALSE
)
kable(comp, booktabs = TRUE) |>
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = TRUE, font_size = 11) |>
column_spec(1, bold = TRUE)
```
## SDID Applied to Proposition 99
```{r sdid-california}
#| label: scm-sdid-california
#| tbl-cap: "Estimated effect of Proposition 99 (reduction in per capita cigarette packs) under three estimators. Note that the three estimates differ not only in magnitude but in their identifying assumptions, comparing them is itself informative about which assumptions bind."
if (!requireNamespace("synthdid", quietly = TRUE)) {
if (!requireNamespace("remotes", quietly = TRUE))
install.packages("remotes", repos = "https://cloud.r-project.org", quiet = TRUE)
remotes::install_github("synth-inference/synthdid", quiet = TRUE)
}
library(synthdid)
data("california_prop99")
setup <- panel.matrices(california_prop99)
tau_sdid <- synthdid_estimate(setup$Y, setup$N0, setup$T0)
tau_did <- did_estimate(setup$Y, setup$N0, setup$T0)
tau_sc <- sc_estimate(setup$Y, setup$N0, setup$T0)
results_df <- data.frame(
Method = c("SDID (Arkhangelsky et al. 2021)", "SCM (Abadie et al. 2010)", "DiD"),
Estimate = round(c(as.numeric(tau_sdid), as.numeric(tau_sc), as.numeric(tau_did)), 1),
Note = c(
"Preferred when multiple units available, dual weights over units and time",
"Classic baseline, pure convex interpolation, single treated unit",
"Overestimates; parallel trends violated (CA declining faster pre-treatment)"
)
)
kable(results_df, booktabs = TRUE,
col.names = c("Method", "Estimate (packs per capita)", "Note")) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE, font_size = 12) |>
column_spec(3, color = "grey40", italic = TRUE)
```
```{r fig-sdid}
#| label: scm-fig-sdid
#| fig-cap: "SDID visualisation: weighted trajectories and estimated treatment effect for California Proposition 99."
#| warning: false
plot(tau_sdid)
```
The pattern across estimators is informative. DiD overestimates the effect because California was already declining faster than the average state, its pre-treatment trend is steeper, so a naive before-after comparison attributes too much to the policy. SCM corrects this by matching the pre-treatment trajectory more carefully. SDID lands between the two, adjusting for both unit-level differences (through optimised unit weights) and time-period anomalies (through time weights). The difference between SCM and SDID estimates reflects partly the different treatment of atypical pre-treatment periods and partly the intercept term that SDID adds to the weight construction.
::: {.callout-tip}
## When to prefer SCM vs. SDID
Use **classic SCM** when you have one treated unit, a long pre-treatment series, excellent pre-treatment fit, and need the strongest possible transparency about counterfactual construction. The path plot, actual trajectory vs. synthetic trajectory, does the communication work for non-technical audiences.
Use **SDID** when you have multiple treated units, imperfect pre-treatment fit, or need valid asymptotic standard errors for formal inference. When in doubt, estimate both and compare, a large discrepancy is itself informative about which assumptions are binding.
:::
# The `tidysynth` Workflow {#sec-tidysynth}
For a more readable, modular pipeline, the `tidysynth` package (Dunford, 2021) provides a tidyverse-compatible pipe interface over the same SCM algorithm:
```{r tidysynth-demo}
#| eval: false
library(tidysynth)
smoking |>
synthetic_control(
outcome = cigsale,
unit = state,
time = year,
i_unit = "California",
i_time = 1989,
generate_placebos = TRUE
) |>
generate_predictor(time_window = 1980:1988,
lnincome = mean(lnincome, na.rm = TRUE),
retprice = mean(retprice, na.rm = TRUE),
age15to24 = mean(age15to24, na.rm = TRUE)) |>
generate_predictor(time_window = 1975, cigsale_1975 = mean(cigsale, na.rm = TRUE)) |>
generate_predictor(time_window = 1980, cigsale_1980 = mean(cigsale, na.rm = TRUE)) |>
generate_predictor(time_window = 1988, cigsale_1988 = mean(cigsale, na.rm = TRUE)) |>
generate_weights(optimization_window = 1970:1988) |>
generate_control() |>
plot_trends()
```
::: {.callout-tip}
## The code is the easy part
Running `synth()` or `synthetic_control()` takes minutes. The real analysis is in the diagnostics: inspect the path plot, examine weight sparsity, verify covariate balance, run both placebo tests, and check leave-one-out stability. A result that passes all of these is worth presenting to a CFO. One that skips any of them is not.
:::
# Diagnostic Checklist for Practitioners {#sec-checklist}
```{r tbl-checklist}
#| label: scm-tbl-checklist
#| tbl-cap: "Quality and robustness checklist for Synthetic Control applications."
checklist <- data.frame(
Diagnostic = c(
"Pre-treatment fit (MSPE_pre)",
"Covariate balance",
"Weight sparsity",
"Convex hull check",
"In-space placebo test",
"In-time placebo test",
"Donor pool robustness (leave-one-out)",
"SUTVA / spillover check",
"RMSPE post/pre ratio"
),
What_to_Verify = c(
"MSPE_pre near 0; synthetic series visually tracks the actual pre-treatment path",
"tab.pred: small differences between treated and synthetic across all predictors",
"Few positive weights (interpretability); many near-zero weights is a geometric feature, not a flaw",
"If treated unit is an extreme outlier in all dimensions, SCM is not applicable, use ASCM",
"Real gap >> placebo gap distribution; exact permutation p-value providing evidence against the null",
"Simulate treatment k years early, gap after fictitious treatment date should be near zero",
"Re-estimate excluding each control with positive weight; check estimate stability",
"Check for cross-border purchasing, diaspora effects, or industry spillovers",
"Ratio >> 1 for treated unit; ideally the highest ratio in the full distribution"
),
Red_Flag = c(
"High MSPE_pre: synthetic does not track pre-trends, SCM not valid, consider ASCM",
"Large balance differences: treated unit outside convex hull, dangerous extrapolation",
"All weights near 1/J: synthetic ≈ simple mean, no added value over naive DiD",
"Treated unit far outside hull in all dimensions, SCM not recommended",
"Real gap inside placebo cloud, effect indistinguishable from noise",
"Large gap before fictitious treatment date, pre-existing confounders or anticipation effects",
"Estimate changes substantially when one control is dropped, fragile result",
"Contaminated controls, bias (attenuation or amplification) in either direction",
"Ratio near 1 for treated unit: effect not distinguishable from random variation"
),
check.names = FALSE
)
kable(checklist, booktabs = TRUE) |>
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = TRUE, font_size = 11) |>
column_spec(1, bold = TRUE, width = "22%") |>
column_spec(3, color = "darkred", width = "35%")
```
## Decision Tree: Which Method to Use?
```{r tbl-decision}
#| label: scm-tbl-decision
#| tbl-cap: "Decision tree for selecting the appropriate estimator based on data structure and pre-treatment fit."
decision <- data.frame(
Situation = c(
"1 treated unit, large T₀, excellent pre-fit",
"1 treated unit, large T₀, reasonable pre-fit",
"1 treated unit, small T₀, poor pre-fit",
"Multiple treated units, large N",
"Multiple treated units, staggered adoption",
"Parallel trends uncertain"
),
Method = c(
"Classic SCM (Abadie et al. 2010)",
"SCM + placebos + leave-one-out robustness",
"ASCM (Ben-Michael et al. 2021)",
"SDID (Arkhangelsky et al. 2021)",
"SDID with per-cohort estimation",
"SDID (more robust than DiD or SC in isolation)"
),
Rationale = c(
"Sparse, interpretable weights; no extrapolation in observed covariate space",
"Placebos quantify significance; LOO verifies robustness against individual donors",
"Ridge correction reduces bias from imperfect convex hull interpolation",
"Valid asymptotic inference in large panels (normality of SDID estimator)",
"Handles staggered rollout naturally via per-cohort estimation",
"SDID achieves consistency if either DiD or SC conditions hold (partially)"
),
R_Package = c("Synth / tidysynth", "Synth / tidysynth", "augsynth", "synthdid",
"synthdid / causalverse", "synthdid"),
check.names = FALSE
)
kable(decision, booktabs = TRUE) |>
kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE, font_size = 11) |>
column_spec(1, bold = TRUE, width = "25%") |>
column_spec(4, color = "#005F73")
```
# Practical Interpretation {#sec-interpretation}
The post-treatment gap $\hat{\alpha}_{1t} = Y_{1t} - \hat{Y}_{1t}^{(0)}$ is the estimated **ATT (Average Treatment Effect on the Treated)** at each point in time $t > T_0$. It answers: *What is the difference between what happened to the treated unit and what would have happened in the counterfactual world where treatment never occurred?*
This gap is the estimated causal effect for a specific unit at a specific time. For the fintech, it is the difference between Portugal's actual 90-day retention and the retention that a weighted combination of 11 other markets would predict. To translate the raw lift (+34%) into a causal estimate (+5 pp): the raw lift captures the total change in Portugal post-launch; the SCM gap decomposes that change into a counterfactual component (what would have happened anyway, absorbed by the synthetic trajectory) and a causal component (the residual gap). The path from raw headline to causal estimate to revenue impact follows this pipeline: 34% total observed lift → SCM decomposition → ~5 pp causal effect → multiply by per-user LTV and active user base → €2.3M incremental annual revenue.
The practical workflow for decision-makers:
1. **Estimate the causal gap** in the primary metric (e.g., retention).
2. **Aggregate across post-treatment periods**, compute the average post-treatment ATT, not just the point estimate at one time.
3. **Convert to financial terms**, multiply the retention lift by per-user LTV and the affected user base.
4. **Stress-test the result**, report the permutation p-value, the leave-one-out range, and the comparison with DiD and SDID estimates.
5. **Present the range, not just the point**, the honest answer is "the causal effect is between X and Y, with an exact permutation p-value providing evidence against the null," not "the effect is exactly Z."
# Conclusion {#sec-conclusion}
The Synthetic Control Method constructs an artificial counterfactual by finding an optimally weighted convex combination of untreated units that reproduces the treated unit's pre-treatment trajectory. It is the right tool precisely when DiD fails: a single treated unit, non-parallel pre-trends, and a long pre-treatment series that can credibly establish the quality of the match.
The method's key guarantee, the convex hull constraint, ensures that the synthetic control is always a weighted average of real observations in observed covariate space, never an explicitly extrapolated artifact. In practice, this buys you two things that no other method provides at this level of transparency: no invisible extrapolation (every weight is non-negative and corresponds to a real unit), and a directly inspectable pre-treatment fit that serves as the identifying assumption made visible. The identification still relies on the underlying latent factor model, but the pre-treatment fit gives you observable evidence about whether that model holds.
When the pre-treatment fit is imperfect because the treated unit falls outside the convex hull, ASCM (Ben-Michael et al. 2021) adds a ridge-corrected outcome model that relaxes the interpolation constraint in a controlled manner, navigating the bias-variance tradeoff between pure SCM and unconstrained regression. When the data structure includes multiple treated units or the analyst needs valid asymptotic standard errors, SDID (Arkhangelsky et al. 2021) applies dual weighting over both units and time periods, achieving robustness that neither DiD nor SCM can deliver alone.
The choice of estimator should follow from the data structure and pre-treatment fit, not from convenience, software availability, or disciplinary convention. The diagnostics, placebo tests, and sensitivity analyses presented here are not optional appendages; they are the substance of the causal claim.
---
# References {#sec-references}
Abadie, A. (2020). Using synthetic controls: Feasibility, data requirements, and methodological aspects. *Journal of Economic Literature*, 58(2), 391–424. <https://doi.org/10.1257/jel.20191450>
Abadie, A., & Gardeazabal, J. (2003). The economic costs of conflict: A case study of the Basque Country. *American Economic Review*, 93(1), 113–132. <https://doi.org/10.1257/000282803321455188>
Abadie, A., Diamond, A., & Hainmueller, J. (2010). Synthetic control methods for comparative case studies: Estimating the effect of California's tobacco control program. *Journal of the American Statistical Association*, 105(490), 493–505. <https://doi.org/10.1198/jasa.2009.ap08746>
Abadie, A., Diamond, A., & Hainmueller, J. (2011). Synth: An R package for synthetic control methods in comparative case studies. *Journal of Statistical Software*, 42(13), 1–17. <https://doi.org/10.18637/jss.v042.i13>
Abadie, A., Diamond, A., & Hainmueller, J. (2015). Comparative politics and the synthetic control method. *American Journal of Political Science*, 59(2), 495–510. <https://doi.org/10.1111/ajps.12116>
Arkhangelsky, D., Athey, S., Hirshberg, D. A., Imbens, G. W., & Wager, S. (2021). Synthetic difference in differences. *American Economic Review*, 111(12), 4088–4118. <https://doi.org/10.1257/aer.20190159>
Athey, S., & Imbens, G. W. (2017). The state of applied econometrics: Causality and policy evaluation. *Journal of Economic Perspectives*, 31(2), 3–32. <https://doi.org/10.1257/jep.31.2.3>
Ben-Michael, E., Feller, A., & Rothstein, J. (2021). The augmented synthetic control method. *Journal of the American Statistical Association*, 116(536), 1789–1803. <https://doi.org/10.1080/01621459.2021.1929245>
Cunningham, S. (2021). *Causal inference: The mixtape*. Yale University Press. <https://mixtape.scunning.com>
Dunford, E. (2021). *tidysynth: A tidy implementation of the synthetic control method* [R package]. <https://github.com/edunford/tidysynth>
Hey Stella. (2024). A practitioner's guide to weighted synthetic control methods for incrementality testing. <https://www.stellaheystella.com/blog/a-practitioners-guide-to-weighted-synthetic-control-methods-for-incrementality-testing>
Holland, P. W. (1986). Statistics and causal inference. *Journal of the American Statistical Association*, 81(396), 945–960.
Huntington-Klein, N. (2021). *The effect: An introduction to research design and causality*. CRC Press. <https://theeffectbook.net>
Mendez, C. (2023). Basic synthetic control with R. <https://carlos-mendez.org/post/r_basic_synthetic_control/>
Mendez, C. (2024). R synthetic control tutorial. <https://carlos-mendez.quarto.pub/r-synthetic-control-tutorial/>
Nguyen, M. (n.d.). Synthetic difference-in-differences. In *A guide on data analysis* (Chapter 36). <https://mike-data-analysis.share.connect.posit.cloud/sec-synthetic-difference-in-differences.html>